DEV Community

Cover image for Why Fixing the Error Isn't the Same as Fixing the Bug
kauddoc
kauddoc

Posted on AI-assisted

Why Fixing the Error Isn't the Same as Fixing the Bug

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
};
Enter fullscreen mode Exit fullscreen mode

Suppose production reports:

TypeError: Cannot read properties of null
Enter fullscreen mode Exit fullscreen mode

The obvious fix might be:

const result = await analyzeProject(projectId);

return {
  score: result?.score ?? 0
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

For example:

Symptom:
500 Internal Server Error

Immediate failure:
result.score throws

Root cause:
analysis service returned null after an
unexpected external API response
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The request goes through:

Browser
   ↓
API Route
   ↓
Authentication
   ↓
Controller
   ↓
Analysis Service
   ↓
Database
   ↓
External Analysis API
Enter fullscreen mode Exit fullscreen mode

The user receives:

500 Internal Server Error
Enter fullscreen mode Exit fullscreen mode

The stack trace points to:

const report = result.report;
Enter fullscreen mode Exit fullscreen mode

You could patch that line.

But let's trace backwards.

Step 1: The controller

const result = await analyzeProject(projectId);

return result.report;
Enter fullscreen mode Exit fullscreen mode

The controller assumes result is valid.

Step 2: The analysis service

const response = await externalAnalysis(project);

if (!response.ok) {
  return null;
}

return processResponse(response);
Enter fullscreen mode Exit fullscreen mode

Now we know that null is possible.

Step 3: The external request

const response = await fetch(ANALYSIS_URL);
Enter fullscreen mode Exit fullscreen mode

Suppose the external service returns:

504 Gateway Timeout
Enter fullscreen mode Exit fullscreen mode

Now we have a chain:

External API timeout
        ↓
response.ok = false
        ↓
analysis service returns null
        ↓
controller assumes result exists
        ↓
result.report crashes
        ↓
500 response
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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) {
  ...
}
Enter fullscreen mode Exit fullscreen mode

Does it guarantee:

User always exists
Enter fullscreen mode Exit fullscreen mode

or:

User may not exist
Enter fullscreen mode Exit fullscreen mode

Those are completely different contracts.

If a user may not exist:

const user = await getUser(id);

if (!user) {
  return notFound();
}
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The crash occurs at E.

But perhaps the data became invalid at B.

A
 ↓
B  ← first invalid state
 ↓
C
 ↓
D
 ↓
E  ← visible failure
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

and invoice is unexpectedly null.

Don't immediately modify this line.

Trace where invoice came from:

invoice
  ↑
getInvoice()
  ↑
invoiceId
  ↑
payment
  ↑
webhook
  ↑
payment provider
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Each side may look correct independently.

The problem can be the contract between them.

For example, Service A expects:

{
  "status": "completed"
}
Enter fullscreen mode Exit fullscreen mode

Service B starts returning:

{
  "state": "completed"
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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"
});
Enter fullscreen mode Exit fullscreen mode

That tells you something important.

The application expects organizationId to exist.

Now imagine a production bug where:

user.organizationId
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The danger of "make the error go away"

There are several common debugging patches that deserve extra scrutiny.

Optional chaining

data?.user?.organization?.name
Enter fullscreen mode Exit fullscreen mode

Useful when the data is genuinely optional.

Dangerous when the data is required.

Default values

count ?? 0
Enter fullscreen mode Exit fullscreen mode

Useful when zero is a legitimate fallback.

Dangerous when zero hides missing data.

Empty arrays

items || []
Enter fullscreen mode Exit fullscreen mode

Useful when an empty collection is valid.

Dangerous when a failed query should produce an error.

Catching everything

try {
  await operation();
} catch {
  return null;
}
Enter fullscreen mode Exit fullscreen mode

This can transform an actionable failure into silent corruption.


A better debugging loop

Instead of:

Error
  ↓
Patch
  ↓
Deploy
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

Weak answer:

Because result was 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 accessed result.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.
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Sometimes that's exactly what you need.

But the difficult question is:

Should result actually 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
Enter fullscreen mode Exit fullscreen mode

It should be:

AI
 ↓
Investigation
 ↓
Evidence
 ↓
Hypothesis
 ↓
Validation
 ↓
Root cause
 ↓
Fix
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)