Why Fixing the Error Isn't the Same as Fixing the Bug
A bug appears in production.
You find the line that crashes.
You add a null check.
The error disappears.
You deploy.
Problem solved.
Except sometimes it isn't.
The application stops crashing, but the underlying problem is still there.
This is one of the most dangerous patterns in software debugging:
fixing the symptom instead of fixing the root cause.
A crash is often just the final visible event in a much longer chain of failures.
The error is not always the problem
Consider this code:
const result = await analyzeProject(projectId);
return {
score: result.score
};
Suppose production reports:
TypeError: Cannot read properties of null
The obvious fix might be:
const result = await analyzeProject(projectId);
return {
score: result?.score ?? 0
};
The application no longer crashes.
It looks fixed.
But what actually happened?
Maybe analyzeProject() is supposed to return a result every time.
Maybe it returned null because:
- an external API timed out
- a database query failed
- a background job hasn't completed
- a response couldn't be parsed
- a dependency changed its response format
- an earlier validation step silently failed
Returning 0 doesn't solve any of those problems.
It only prevents the final error from being visible.
The system may now be producing an incorrect result instead.
Symptom vs. root cause
A useful way to think about debugging is to separate three things:
Symptom
↓
Immediate failure
↓
Root cause
For example:
Symptom:
500 Internal Server Error
Immediate failure:
result.score throws
Root cause:
analysis service returned null after an
unexpected external API response
The immediate failure tells you where the system broke.
The root cause explains why it broke.
Those are not necessarily the same thing.
A simple example
Imagine this request:
POST /api/projects/analyze
The request goes through:
Browser
↓
API Route
↓
Authentication
↓
Controller
↓
Analysis Service
↓
Database
↓
External Analysis API
The user receives:
500 Internal Server Error
The stack trace points to:
const report = result.report;
You could patch that line.
But let's trace backwards.
Step 1: The controller
const result = await analyzeProject(projectId);
return result.report;
The controller assumes result is valid.
Step 2: The analysis service
const response = await externalAnalysis(project);
if (!response.ok) {
return null;
}
return processResponse(response);
Now we know that null is possible.
Step 3: The external request
const response = await fetch(ANALYSIS_URL);
Suppose the external service returns:
504 Gateway Timeout
Now we have a chain:
External API timeout
↓
response.ok = false
↓
analysis service returns null
↓
controller assumes result exists
↓
result.report crashes
↓
500 response
The crash happened in the controller.
The problem started much earlier.
Why defensive programming can sometimes hide bugs
Defensive programming is useful.
Checks like this are often necessary:
if (!user) {
return null;
}
The problem occurs when defensive code is used to hide an invalid state that should never exist.
For example:
const organization = await getOrganization(id);
return organization?.name ?? "Unknown";
Maybe that's correct.
But maybe every authenticated user is guaranteed to belong to an organization.
If that's the case, silently returning "Unknown" could hide:
Broken user → organization relationship
Now the system keeps running with invalid data.
That can be worse than a crash.
A crash tells you something is wrong.
A silent incorrect result can go unnoticed for months.
Ask what the code is supposed to guarantee
When you encounter an error, don't only ask:
"How can I prevent this exception?"
Ask:
"What should this function guarantee?"
For example:
async function getUser(id) {
...
}
Does it guarantee:
User always exists
or:
User may not exist
Those are completely different contracts.
If a user may not exist:
const user = await getUser(id);
if (!user) {
return notFound();
}
might be appropriate.
If the user is guaranteed to exist at this point in the application:
const user = await getUser(id);
if (!user) {
throw new Error("Invariant violated: user does not exist");
}
might be more useful.
The correct behavior depends on the system's actual contract.
Look for the first invalid state
One of the most effective debugging questions is:
Where did the data first become invalid?
Imagine:
A → B → C → D → E → crash
The crash occurs at E.
But perhaps the data became invalid at B.
A
↓
B ← first invalid state
↓
C
↓
D
↓
E ← visible failure
If you only inspect E, you're debugging too late in the chain.
The goal is to move backwards until you find the earliest point where reality stopped matching the program's assumptions.
Follow the data backwards
Suppose you see:
invoice.total
and invoice is unexpectedly null.
Don't immediately modify this line.
Trace where invoice came from:
invoice
↑
getInvoice()
↑
invoiceId
↑
payment
↑
webhook
↑
payment provider
At every step, ask:
- What value entered this function?
- What value came out?
- Was it transformed?
- Was validation performed?
- Could it be null?
- Could it have the wrong shape?
- Is the caller assuming more than the function guarantees?
This often reveals the real problem.
Follow control flow too
Data isn't the only thing you need to trace.
Control flow matters as well.
Consider:
if (user.isAdmin) {
await createReport();
}
return sendReport();
What happens when user.isAdmin is false?
Maybe createReport() never runs, but sendReport() still executes.
The bug isn't necessarily inside either function.
The problem is the relationship between them.
Large applications contain thousands of these relationships.
That's why debugging by opening one file at a time can become extremely slow.
System boundaries are especially important
Root causes often appear where two systems interact.
Common boundaries include:
Frontend → Backend
Backend → Database
Service → Service
Application → Queue
Application → Cache
Application → External API
Application → Payment provider
Application → Authentication provider
Each side may look correct independently.
The problem can be the contract between them.
For example, Service A expects:
{
"status": "completed"
}
Service B starts returning:
{
"state": "completed"
}
Service B may still be functioning correctly.
Service A may also be functioning according to its existing assumptions.
But the integration is broken.
A defensive patch in Service A might hide the problem.
The real fix may require updating the contract.
Git history can reveal why an assumption exists
Sometimes the current code doesn't explain itself.
You see:
if (!response.data) {
return null;
}
Why is this here?
Was it intentional?
Was it added after a production incident?
Was it introduced during a refactor?
Was it copied from another module?
This is where Git history can be extremely useful.
Look at:
- When the line was introduced
- What changed around it
- The commit message
- The previous implementation
- Related changes in the same commit
For example:
Current code:
if (!response.data) {
return null;
}
Git history might reveal that the external API changed its response structure six months ago.
Suddenly the current behavior makes sense.
The bug isn't simply:
"response.data is missing."
The deeper issue is:
"The application still expects the old API response contract."
History gives you context that the current code alone may not provide.
Tests can expose hidden contracts
Tests aren't only for checking whether a fix works.
They can also tell you what the system considers valid behavior.
Suppose you find:
expect(getUser("123")).resolves.toEqual({
id: "123",
organizationId: "org_1"
});
That tells you something important.
The application expects organizationId to exist.
Now imagine a production bug where:
user.organizationId
is undefined.
The test provides evidence that the state is unexpected.
You can then investigate why the contract was violated instead of simply adding:
user?.organizationId
The danger of "make the error go away"
There are several common debugging patches that deserve extra scrutiny.
Optional chaining
data?.user?.organization?.name
Useful when the data is genuinely optional.
Dangerous when the data is required.
Default values
count ?? 0
Useful when zero is a legitimate fallback.
Dangerous when zero hides missing data.
Empty arrays
items || []
Useful when an empty collection is valid.
Dangerous when a failed query should produce an error.
Catching everything
try {
await operation();
} catch {
return null;
}
This can transform an actionable failure into silent corruption.
A better debugging loop
Instead of:
Error
↓
Patch
↓
Deploy
use:
Error
↓
Reproduce
↓
Collect evidence
↓
Trace execution
↓
Trace data
↓
Find assumptions
↓
Locate first invalid state
↓
Form hypothesis
↓
Try to disprove it
↓
Fix root cause
↓
Add regression test
This takes longer initially.
But it often saves time later.
How to know whether you've found the root cause
A useful test is to ask:
If I remove my patch, can I explain exactly why the original failure occurs?
If the answer is no, you may not understand the bug yet.
Another useful question:
Does my explanation account for the entire chain of events?
For example:
Why did the API return 500?
Weak answer:
Because
resultwas null.
Better answer:
The external analysis service returned an unexpected response. The analysis layer converted that failure into
null, while the controller assumed analysis always produced a result. The controller then accessedresult.report, producing the 500 response.
The second explanation connects the events.
That's what a root-cause explanation should do.
Root cause analysis should produce evidence
A strong debugging report shouldn't just say:
"The problem is probably in the analysis service."
It should show evidence.
For example:
1. Controller expects analysis result to always exist.
2. Analysis service returns null when the external request fails.
3. External API returned a 504 during reproduction.
4. No validation exists between the service and controller.
5. Existing tests don't cover the failed external response.
Now the conclusion is supported by multiple pieces of evidence.
This is much more useful than simply pointing at the line that crashed.
AI makes this more interesting
AI coding assistants are becoming very good at generating patches.
You can give an error to an AI assistant and quickly receive:
if (!result) {
return;
}
Sometimes that's exactly what you need.
But the difficult question is:
Should
resultactually be allowed to be null?
That's a system-level question.
An AI can help you investigate it by:
- Finding the function definition
- Finding all callers
- Tracing dependencies
- Comparing related implementations
- Inspecting tests
- Examining Git history
- Identifying inconsistent assumptions
- Generating regression tests
But the goal shouldn't be:
AI → patch
It should be:
AI
↓
Investigation
↓
Evidence
↓
Hypothesis
↓
Validation
↓
Root cause
↓
Fix
The distinction matters.
A practical root-cause checklist
The next time you encounter a difficult bug, ask:
About the failure
- What exactly failed?
- Where did the error become visible?
- Can I reproduce it?
About the data
- Where did the problematic value originate?
- Where was it transformed?
- When did it first become invalid?
About the code
- What assumptions are being made?
- What does each function actually guarantee?
- Are those guarantees documented or tested?
About the system
- Are there external services involved?
- Did a database, queue, cache, or API behave unexpectedly?
- Could there be a contract mismatch?
About history
- When was the relevant code introduced?
- What changed recently?
- Did a dependency or API contract change?
About the fix
- Does this fix the cause or hide the symptom?
- Could the same problem occur elsewhere?
- Can I write a regression test?
If you can't answer these questions, you may not have reached the root cause yet.
A useful mental model
When debugging, think of the application as a chain of assumptions.
Input
↓
Validation
↓
Transformation
↓
Business logic
↓
Persistence
↓
External systems
↓
Output
Every arrow represents a contract.
Every contract represents an assumption.
And every assumption is a potential failure point.
The job of debugging is not simply to find the broken line.
It's to discover which assumption became false and why.
Why this matters in large codebases
In a small project, you can often hold most of the system in your head.
In a large codebase, you can't.
There may be:
- Hundreds of modules
- Multiple services
- Shared packages
- Background workers
- Database layers
- External APIs
- Feature flags
- Authentication systems
- Queues
- Caches
- Multiple deployment environments
A failure in one component can originate several layers away.
That's why large-codebase debugging requires investigation rather than just code editing.
You need to reconstruct the system's behavior.
This is the problem we're working on with Kauddoc
This distinction between finding an error and investigating its root cause is one of the ideas behind Kauddoc.
The goal is to make repository investigation more systematic:
Repository
↓
Understand structure
↓
Trace relevant code
↓
Follow dependencies
↓
Inspect history
↓
Analyze behavior
↓
Connect evidence
↓
Identify root cause
↓
Explain the fix
The objective isn't simply to generate another patch.
It's to understand why the system behaved the way it did.
Final thought
A successful deployment doesn't necessarily mean a bug was fixed.
Sometimes it only means the application stopped complaining.
The better question isn't:
"How do I make this error disappear?"
It's:
"Why did the system reach this state in the first place?"
That question changes the entire debugging process.
Find the symptom.
Trace the execution.
Follow the data.
Question the assumptions.
Find the first invalid state.
Test the hypothesis.
Then fix the root cause.
Because the best debugging fix isn't the one that makes the error disappear.
It's the one that makes the failure impossible—or at least much harder—to happen again.
Top comments (0)