Side Effects Are Not the Enemy — Uncontrolled Side Effects Are
Youve seen this bug. A function works flawlessly in staging, passes all tests, ships to production — and then, three weeks later, silently returns wrong data. No exception. No crash. Just a number thats off by a factor of two, or a user balance that doesnt add up.
You spend four hours debugging before discovering that some other function, in a completely different module, mutated a shared config object. Congratulations: youve just met a Heisenbug, and it was born from an uncontrolled side effect.
Part 1: The Everything Is Fine Lie
The tricky part about side-effect bugs is that theyre invisible until theyre catastrophic. A function that depends on global state or external mutable data might work correctly 99% of the time — exactly as long as no one else touches that state. The moment another developer adds a seemingly unrelated feature that modifies the same global, your function starts misfiring under specific conditions. Call sequences matter.
Timing matters. The order in which modules initialize matters. None of this shows up in unit tests if your tests dont reproduce the exact execution context of production.
This is the classic Heisenbug signature: a manifestation of non-determinism where the behavior changes the moment you observe it. Whether you add a log, change the test order, or run the code in isolation — the bug suddenly vanishes. The root cause is almost always a hidden state dependency or implicit mutation lurking somewhere in the call chain, making the systems state unpredictable and your debugging sessions a nightmare.
// Classic Heisenbug setup
let taxRate = 0.2;
function calculateInvoiceTotal(items) {
return items.reduce((sum, item) => sum + item.price, 0) * (1 + taxRate);
}
// Somewhere in another module, at some point:
function applyBlackFridayDiscount() {
taxRate = 0.0; // "temporary" override — never cleaned up
}
What This Code Tells Us About the System
The function calculateInvoiceTotal looks clean. It takes input, it returns a value. But it secretly reaches outside its own scope to grab taxRate — a global that any other function can modify at any time. The result of calling calculateInvoiceTotal now depends not just on items, but on the entire execution history of the program up to that point. The function is lying about its dependencies. Thats the real problem.
Part 2: Pure Functions — Beyond the Math Textbook
Most explanations of pure functions pull out the math example: f(x) = x * 2. Always the same output, no side effects. Clean. But that framing makes purity feel academic, like something that lives in Haskell tutorials and nowhere else. In practice, purity is a much more useful engineering property: a pure function is one where, given the same inputs, you always get the same output, and calling the function doesnt change anything outside its own scope. No globals touched, no files written, no database rows modified, no exceptions thrown.
The practical test is referential transparency: if you can replace the function call with its return value and the rest of the program still behaves identically, the function is pure. add(2, 3) → you can replace it with 5 everywhere. Nothing breaks. Try doing that with a function that logs to a file or reads from time().
// Impure — result depends on when you call it
function generateOrderId(userId) {
const timestamp = Date.now(); // external state: current time
console.log(`Generating order for ${userId}`); // side effect: I/O
return `${userId}-${timestamp}`;
}
// Pure — deterministic, no I/O, no hidden dependencies
function generateOrderId(userId, timestamp) {
return `${userId}-${timestamp}`;
}
// Caller decides when to inject the timestamp
The Timestamp Tells the Whole Story
The first version hides two violations: it reads from the system clock (external state) and writes to the console (side effect). Both make the function non-deterministic and hard to test without mocking. The refactored version is boring on purpose — boring means predictable. The caller injects the timestamp, so the function is fully testable with any value you choose. No mocks needed. No test setup. Just inputs and expected outputs.
Part 3: The Myth of 100% Purity
Heres where functional programming evangelists lose the room. If you follow the make everything pure doctrine to its logical endpoint, you end up with a program that does absolutely nothing useful. No database writes. No HTTP calls. No logs. No console output. A perfectly pure program is just a very expensive space heater for your CPU — it consumes energy and produces heat, but no observable effect on the world. Thats not software. Thats philosophy.
The real goal isnt purity for its own sake. Its isolation. Side effects are necessary — the entire point of software is to interact with the world outside itself. What kills codebases isnt side effects in general; its side effects scattered throughout business logic, tangled up with calculations, invisible to callers, and impossible to test without spinning up half your infrastructure.
Part 4: Side Effects — The Usual Suspects
Before you can isolate side effects, you have to recognize them. Four categories show up in almost every codebase, and at least one of them is probably not on your radar yet.
Mutating input arguments is the most insidious because it looks like a performance optimization. You pass an object in, modify it in place, return it — or dont even return it. The callers data is now different. If the caller reuses that object anywhere, congratulations: youve introduced action-at-a-distance. Pass immutable data or return new objects.
Global state dependency is the taxRate example from earlier. The function appears self-contained but secretly reads from or writes to shared mutable state. Config objects, singletons, module-level variables, static class properties — all of these are global state with different syntax.
I/O of any kind — disk reads, network calls, database queries, console output — is a side effect. This isnt a controversial statement, but its implications are. Every function that does I/O is non-deterministic by definition. Network calls can fail. Database records can change between calls. File contents can be modified by another process. I/O belongs at the edges of your system, not buried in business logic.
Throwing exceptions is the one that surprises people. An exception is a hidden output of a function — a second return path that doesnt appear in the function signature. It jumps the call stack in ways the caller may not expect. Unhandled, it propagates up through layers that werent designed to deal with it. This doesnt mean never throw — it means be deliberate. Dont throw as a flow control mechanism inside pure business logic.
// All four suspects in one function — spot them
function processUserRegistration(userData, db, config) {
userData.email = userData.email.toLowerCase(); // 1. Mutating input
if (config.featureFlags.strictMode) { // 2. Global state dependency
validateStrict(userData); // 3. Might throw — hidden output
}
const userId = db.insert(userData); // 4. I/O — non-deterministic
console.log(`Registered: ${userId}`); // 4. More I/O
return userId;
}
One Function, Four Problems
This function is doing too many things in too many dimensions. It mutates its own argument, depends on external config, does database I/O, does console I/O, and might throw an exception — all in eight lines. Testing this requires a real (or mocked) database, a real (or mocked) config object, and careful setup of the input object before every test. This is not a function you can reason about in isolation. And the worst part: it looks completely normal. Most production codebases are full of functions exactly like this.
Part 5: Functional Core, Imperative Shell
This is the pattern that actually works in production codebases. Not Haskell monads. Not IO functors. Just a clean architectural split between two zones: the core, which contains all your business logic as pure functions, and the shell, which handles all the I/O, orchestrates the calls, and deals with the messy world outside your process. Gary Bernhardt called it Functional Core, Imperative Shell back in 2012, and its still the most practical piece of architecture advice everyday backend work.
Think of it this way: the core is your domain model. It knows how to calculate an invoice, validate a registration form, compute discount rules, determine eligibility. It takes data in and returns data out. No I/O, no exceptions thrown as flow control, no hidden dependencies. The shell is everything else — it reads from the database, passes the data into the core, takes the result, and writes it back out. The shell is necessarily impure. Thats fine. Youve contained the mess.
Why does this make testing dramatically easier? Because your core is now testable with zero infrastructure. No database. No HTTP client. No file system. No mocks. You pass in a plain data structure, you assert on the return value. Thats it. The shell, meanwhile, is thin enough that integration tests cover it adequately without much ceremony. Mocks, when you need them everywhere, are a sign that your business logic is entangled with your I/O. Theyre a symptom, not a solution.
// SHELL: handles I/O, orchestrates, stays thin
async function handleUserRegistration(rawInput, db, mailer) {
const userData = parseAndSanitize(rawInput); // I/O-free normalization
const validation = validateRegistration(userData); // pure core function
if (!validation.isValid) {
return { success: false, errors: validation.errors };
}
const newUser = buildUserRecord(userData, Date.now()); // pure: deterministic
const savedUser = await db.users.create(newUser); // I/O: in the shell
await mailer.sendWelcome(savedUser.email); // I/O: in the shell
return { success: true, userId: savedUser.id };
}
// CORE: pure functions, zero I/O, fully testable without mocks
function validateRegistration(userData) {
const errors = [];
if (!userData.email.includes('@')) errors.push('Invalid email');
if (userData.password.length < 8) errors.push('Password too short');
return { isValid: errors.length === 0, errors };
}
function buildUserRecord(userData, timestamp) {
return {
email: userData.email,
passwordHash: hashPassword(userData.password),
createdAt: timestamp,
role: 'user',
};
}
The Split Pays Off Immediately
The two core functions — validateRegistration and buildUserRecord — can be tested with a single line each. No db setup. No mail server. No async overhead. The shell function is the only thing that needs integration coverage, and its so thin that its behavior is almost entirely determined by what the core returns. When something breaks in production, you know exactly which zone to look in.
Part 6: Practical Refactoring in Three Steps
Take any dirty function and apply this sequence. It works every time.
Step 1: Extract the calculations. Find every piece of logic that computes something — validates, transforms, derives a value — and pull it into its own function with no I/O. Dont worry about perfect naming yet. Just separate things that compute from things that act.
Step 2: Pass dependencies explicitly. If your calculation needs the current time, pass it in. If it needs a config value, pass it in. Stop reading from globals inside logic functions. This makes every hidden dependency visible in the function signature — which is exactly where it belongs.
Step 3: Push I/O to the last possible moment. Restructure the call site so that all reads happen first (fetch from db, read config, get current time), then all computation happens in the pure core, then all writes happen last (save to db, send email, log the result). Read — Process — Write. In that order, every time.
// BEFORE: dirty, entangled, untestable in isolation
function applyInvoiceDiscount(invoiceId, db, config) {
const invoice = db.getInvoice(invoiceId); // I/O
if (config.discounts.enabled) { // global dep
invoice.total *= (1 - config.discounts.rate); // mutation + global
invoice.updatedAt = new Date(); // I/O (time)
db.saveInvoice(invoice); // I/O
}
}
// AFTER: pure core + thin shell
function calculateDiscountedTotal(total, discountRate) {
return total * (1 - discountRate);
}
async function applyInvoiceDiscount(invoiceId, db, discountConfig, now) {
const invoice = await db.getInvoice(invoiceId); // Read
if (!discountConfig.enabled) return;
const newTotal = calculateDiscountedTotal( // Process (pure)
invoice.total,
discountConfig.rate
);
await db.saveInvoice({ ...invoice, total: newTotal, updatedAt: now }); // Write
}
The Refactor Didnt Add Complexity — It Revealed It
The after version is slightly longer. Thats not a failure; thats the hidden complexity of the before version becoming explicit. calculateDiscountedTotal is now testable with two numbers and an assertion. The shell function is now a clear sequence: read, compute, write. Anyone reading this code for the first time can trace the data flow without reverse-engineering it from side effects.
Part 7: Predictability Over Cleverness
Heres the uncomfortable truth about seniority: its not about knowing more patterns. Its about writing less surprising code. Junior devs write clever functions that do five things at once. Mid-level devs learn about pure functions and want to rewrite everything in a functional style. Senior devs ask one question before merging anything: Will this wake someone up at 3 AM?
Pure functions dont wake people up. Functions with hidden dependencies do. Functions that mutate their arguments do. Functions that reach into global state, call the network, throw undocumented exceptions — these are the ones that produce incident reports. The Functional Core, Imperative Shell pattern isnt elegant for elegances sake. Its a blast door between your business logic and the chaos of the real world.
Youre not going to eliminate I/O. Youre not going to build a 100% pure codebase. What you can do is stop treating side effects as a natural, unmanageable part of writing functions, and start treating them as infrastructure — something you design, contain, and push to the edges. The difference between a codebase thats a joy to work on and one thats a minefield isnt the language, the framework, or the architecture diagram.
Its whether the person who wrote it respected the boundary between logic and action. That boundary, consistently maintained, is what separates code that ages well from code that rots.
Written by: