AlgoMaster Logo

Debugging Techniques

Low Priority38 min readUpdated June 6, 2026

Debugging is the part of programming that takes the most time and gets the least training. A common approach is trial and error: sprinkle Console.WriteLine calls until something prints, then move on. C# and the .NET tooling can do much better than that. This chapter walks through the debugging tools built into Visual Studio, VS Code, and Rider, plus the production-grade tools to use when no debugger is attached.

Payoffs of Debugging Skill

A senior C# developer spends roughly as much time reading and debugging code as writing it. The gap between someone who fixes a tricky bug in fifteen minutes and someone who spends a day on the same bug is rarely about cleverness. It's about technique: knowing where to set a breakpoint, what state to inspect, which window shows the right view, and when to stop using the debugger and switch to a log or a memory dump instead.

Three concrete payoffs:

  • Faster turnaround. A conditional breakpoint can replace a build-test-build-test loop that takes five minutes per cycle. The fix that used to take an hour takes ten minutes.
  • Clearer understanding of behavior. Stepping through code shows what your program actually does, which often differs from what you think it does. That gap is where bugs live.
  • Production confidence. When a customer reports a bug you can't reproduce locally, you need tools that work on a running server. dotnet-dump, dotnet-trace, and dotnet-counters exist for this situation. Knowing them turns "we'll need to repro this" into "I'll have an answer in twenty minutes."

The rest of the chapter is organized roughly from the cheapest tools (a single breakpoint) to the heaviest (analyzing a memory dump from a production process). Use whichever fits the bug at hand.

The Debugging Mindset

Tools matter, but mindset matters more. Bugs are easier to fix when you treat debugging as a small scientific experiment instead of a random walk through the code.

The loop looks like this:

Each cycle proves or rules out one specific belief about the program. A common failure mode is changing several things at once: "I'll fix the null check, refactor that loop, and try a different LINQ query." If the bug goes away, it's unclear which change fixed it, and one of the other "fixes" may have introduced a new bug that won't surface until next week.

The discipline:

  • Reproduce the bug reliably before touching anything. If it can't be reproduced, there's no way to tell whether the fix worked.
  • Form a single hypothesis. "The total is wrong because the discount runs before the tax." Make it specific enough to test.
  • Change one thing at a time. Given two ideas, test them one after the other.
  • Write down what was tried. Two hours into a hard bug, it's easy to re-test something already ruled out.

Consider an order that shows a total of $80 instead of the expected $72:

Three hypotheses:

  1. The tax is being applied before the discount.
  2. The discount isn't subtracting from the subtotal.
  3. The total formula adds tax and discount instead of subtracting the discount.

Each is testable with one breakpoint and one watch. Pick one, test it, move on. Don't refactor until you know which hypothesis is right.

Debugger Basics in Visual Studio, VS Code, and Rider

The three main IDEs for C# (Visual Studio on Windows, Rider cross-platform, and VS Code with the C# Dev Kit extension) share the same debugger keys, with one or two differences. Learning the keys once allows switching tools without relearning the workflow.

ActionKeyWhat it does
Start debuggingF5Builds, launches the app, attaches the debugger
Step overF10Runs the current line, stops on the next
Step intoF11Steps into the method call on the current line
Step outShift+F11Runs until the current method returns, then stops
Run to cursorCtrl+F10Runs until execution reaches the cursor line
ContinueF5Resumes execution until the next breakpoint
Stop debuggingShift+F5Terminates the debuggee
Toggle breakpointF9Adds or removes a breakpoint on the current line
RestartCtrl+Shift+F5Stops and restarts the debug session

A small program makes these concrete. Save it, set a breakpoint on the line that calls CalculateTotal, and press F5:

When the debugger stops on the call line:

  • F10 (step over) runs CalculateTotal as one unit and stops on Console.WriteLine.
  • F11 (step into) jumps into the body of CalculateTotal and stops on decimal subtotal = ....
  • Once inside, F10 walks line by line through the method.
  • Shift+F11 runs the rest of the method body, returns, and stops on Console.WriteLine.

A summary of the choices:

The key skill is choosing the right step. Stepping into every method, including framework methods like Console.WriteLine, drowns the session in BCL internals. Step over until reaching the suspicious line, then step into. After stepping into something unintended, Shift+F11 gets back out.

Each step keystroke is a round-trip through the debugger. On a hot loop with 10,000 iterations, stepping is unworkable. Use a conditional breakpoint to stop only on the iteration that matters.

Breakpoints: Line, Conditional, Hit Count, Function

A breakpoint tells the debugger "stop here." That basic form, the line breakpoint, is the one everyone learns first. The other three forms exist because line breakpoints get noisy fast.

Line breakpoints. Click in the gutter next to a line, or press F9 with the cursor on the line. Execution stops on that line every time.

Conditional breakpoints. Right-click the breakpoint glyph and add a condition. The debugger evaluates the condition each time execution reaches the line and stops only when the condition is true. The condition can be any C# expression that's valid in the current scope.

Consider a list of orders where the bug appears only on the order for customer Charlie:

Put a breakpoint on the first line inside ProcessOrder, then add the condition order.Customer == "Charlie". The debugger now stops only on Charlie's iteration. The other three run through at full speed.

Hit count breakpoints. Useful when a bug appears on the 47th iteration of a loop. Right-click the breakpoint and set "Hit count = 47" (or "Hit count >= 47", or "Hit count is a multiple of 10"). The debugger keeps a counter for the breakpoint and stops when the counter matches.

Function breakpoints. Sometimes the target line isn't visible (it's in a method that isn't open, or it's in compiled framework code). A function breakpoint stops on any call to a named method. In Visual Studio, use Debug > New Breakpoint > Function Breakpoint, then type the method name like MyApp.Orders.Calculator.CalculateTotal. Useful for catching every call site without scrolling through the file.

A table of the four kinds:

TypeWhen to use
LineThe simple case: stop here every time
ConditionalStop only when an expression evaluates to true
Hit countStop on the Nth time the line runs
FunctionStop on any call to a method by name, no matter where it lives

Tracepoints: Logging Without Code Changes

A tracepoint is a breakpoint that doesn't stop the program. Instead, it prints a message to the debugger output window and continues. The advantage is that tracepoints can be added, changed, and removed without recompiling or modifying source.

In Visual Studio, right-click a breakpoint and pick "Actions." Check "Log a message to Output Window" and type a message. Inside curly braces, any expression in scope can be referenced. Uncheck "Continue execution" only if the tracepoint should also break.

Example. Add a tracepoint inside the loop above with the message:

When the program runs, the Output window shows:

The $HITCOUNT token is a built-in. Others include $CALLSTACK, $FUNCTION, $PID, $TID, and $TIME. The curly-brace form {expression} evaluates any C# expression visible at the tracepoint location.

When tracepoints beat both Console.WriteLine and breakpoints:

  • The bug only reproduces in a release build where logging wasn't added.
  • A UI app stops at a breakpoint and freezes the window, hiding visual state.
  • A quick "what's the value here on every iteration?" is needed without flooding the actual output of the program.
  • Timing matters and the round-trip cost of a real breakpoint distorts the measurement.

Tracepoints don't stop the program, but evaluating expressions and writing to the output window does cost time. They're far cheaper than breakpoints, but on a tight loop (millions of iterations), they'll still slow things down. Remove them or convert to actual logging when the investigation ends.

The Debugger Windows: Watch, Locals, Autos, Call Stack, Immediate

When the debugger is paused, the IDE shows several windows alongside the source. Each answers a different question.

Locals. Every local variable in the current scope. The IDE picks the list. Use this for a quick overview without typing anything.

Autos. The variables the debugger thinks are interesting on the current line. In Visual Studio, that means variables read or written by the current statement and the previous one. Useful for spotting recent changes without scanning the full Locals list.

Watch. Variables and expressions added explicitly. Type any C# expression: order.Total, orders.Count, orders[3].Total > 100. The debugger evaluates the expression each time execution stops. Watch is the main tool for non-trivial debugging.

Call Stack. The chain of method calls leading to the current line. Each frame shows the method, file, and line. Click a frame to switch the Locals/Watch view to that frame's variables. Use this when an exception bubbles up from somewhere deep and the caller chain matters.

Immediate. A REPL pinned to the debug session. Type any C# expression and the debugger evaluates it in the current scope. Calling methods, mutating state, and trying out hypotheticals all work. Type orders.Sum(o => o.Total) to see the running total, or ProcessOrder(orders[2]) to invoke a method with a chosen argument.

A small worked example. The program is paused inside CalculateTotal:

The windows show:

WindowContents
Localsprice = 29.99, quantity = 3, discountRate = 0.10, subtotal = 89.97, discount = 8.997
Autossubtotal = 89.97, discount = 8.997
Watch (you added)subtotal - discount shows 80.973
Call StackProgram.CalculateTotal(29.99, 3, 0.10) <- Program.<Main>$()
Immediate> subtotal * 2 prints 179.94

The order in which to use them:

  1. Locals first for a quick scan of "everything in scope right now."
  2. Watch to track one specific expression across multiple steps.
  3. Call Stack when the call chain matters.
  4. Immediate to ask a question or try a fix without restarting.

A practical technique: paused in the middle of a bug, type the suspect expression into Immediate. If it returns the wrong answer, the bug is localized to that expression. If it returns the right answer, the bug is elsewhere, often in how the result is used.

DataTips, Pinning Values, and Watch on Hover

DataTips are the pop-ups that appear when hovering over a variable in the source while paused. They show the variable's current value, and for complex objects, an expandable tree of fields and properties.

DataTips have two underused features:

  • Pinning. Click the pin icon on a DataTip and it stays visible after the mouse moves away. Pinned DataTips stick around across debug sessions, attached to the source line they came from. Useful when stepping through a complex flow and keeping three or four key values on screen.
  • Editing. Click into a DataTip value to edit it. Change quantity from 3 to 999 and continue execution. The program runs with the new value. Faster than restarting the debugger to test an edge case.

A small example. While paused on the return line:

Hover over discount. The DataTip shows 8.997. Click the pin, drag it next to subtotal. Both are now visible side by side during stepping. Click into subtotal and change it to 1000m. Press F5. The return value, and the printed total, reflect the change.

Editing values mid-session is useful for "what if?" exploration. To check behavior when stock is zero, pause, edit the stock variable to 0, continue. To test the discount path on a customer other than Charlie, edit the customer name in place. No new test data and no restart required.

Edited values don't persist after the session ends. They're a quick-test tool, not a way to fix data. If a fix requires editing values every debug session, the real fix belongs in code or test data.

Customizing the Debugger View with Attributes

By default, the debugger shows objects as Namespace.TypeName with a tree of fields and properties. For simple types that's fine. For domain types that wrap a lot of state, the default view is noisy. Three attributes in System.Diagnostics control what the debugger shows.

`[DebuggerDisplay]`. Defines the one-line summary the debugger shows for the type. Without it, an Order shows as Order. With it, the summary can be any expression.

In the debugger Locals/Watch view, each Order now displays as Alice: $89.97 (Placed) instead of the default Order. The ,nq modifier means "no quotes," which avoids the debugger wrapping string values in literal quotes.

`[DebuggerTypeProxy]`. Defines a separate type that the debugger uses to render the object. Useful for collections or types with hidden state worth surfacing. The proxy type can compute and expose properties the original type doesn't have.

In the debugger, expanding a Cart now shows Items, Total, and ItemCount (the proxy's view), not the internal _items field directly. The RootHidden browsable state inlines the items at the top level, removing the need to drill in.

`[DebuggerStepThrough]` and `[DebuggerNonUserCode]`. Both tell the debugger to skip a method when stepping. The difference is subtle:

  • [DebuggerStepThrough] skips over the method on F11 (step into) but the debugger still stops on breakpoints inside it.
  • [DebuggerNonUserCode] is stronger: the debugger treats the method as framework code, won't step into it, and (with "Just My Code" enabled) won't even break inside it on exceptions.

Use these on small passthrough methods, generated code, or DI container internals that clutter the call stack without adding value:

F11 over GetById skips the one-line lookup, keeping focus on the calling code.

A summary table:

AttributePurpose
[DebuggerDisplay]Customize the one-line summary for a type
[DebuggerTypeProxy]Customize the expandable view for a type
[DebuggerBrowsable]Show, hide, or inline a member in the expansion
[DebuggerStepThrough]Skip over a method on step-into
[DebuggerNonUserCode]Treat a method as framework code, skip entirely

These attributes ship in production assemblies but cost nothing at runtime in a non-debug session. The debugger only consults them when it's attached.

Exception Settings

By default, the debugger stops only on unhandled exceptions: ones that bubble all the way up and crash the program. That's fine for obvious crashes, but it misses two important cases:

  • Exceptions that are caught and discarded.
  • Exceptions that are caught, wrapped, and rethrown, where the original stack trace points at the rethrow, not the original throw site.

The Exception Settings window (Debug > Windows > Exception Settings in Visual Studio, similar paths in Rider and VS Code) tells the debugger to stop on thrown exceptions too. Check the box next to "Common Language Runtime Exceptions" and every CLR exception will pause the debugger at the throw site, before any catch block runs.

When to flip it on:

  • Investigating a NullReferenceException that gets caught and turned into "operation failed" with no detail. Breaking on thrown shows where the null came from.
  • Understanding the flow of OperationCanceledException in async code. The default doesn't stop, because the exception is "expected."
  • Hunting an exception that fires inside a third-party library and is discarded before surfacing.

The downside: with "break on thrown" enabled, the debugger pauses constantly during normal operation, because frameworks (especially ASP.NET Core) routinely throw and catch exceptions internally. Narrow the setting to specific exception types instead of the whole category. In the Exception Settings window, check just System.NullReferenceException and leave the rest at "unhandled only."

A small program to demonstrate:

With "break on thrown" off (the default), the debugger doesn't pause. The exception is caught, the program continues. With "break on thrown" on for NullReferenceException, the debugger pauses inside LookupOrder on the line that dereferences customer. The actual cause shows, not the discarded symptom.

"Break on thrown" for broad exception categories will pause many times per second in some apps. Use it surgically: enable for one specific exception type, debug, disable.

Edit and Continue / Hot Reload

Edit and Continue (EnC) allows code changes while paused at a breakpoint, with execution continuing afterward, without restarting. Hot Reload is the modern, broader version: it applies edits to a running app even when not paused. Both save significant time during exploratory debugging.

What you can change without restart (in .NET 6+):

  • Method bodies.
  • Adding new methods, fields, properties, classes.
  • String and numeric literals.
  • Most lambda and LINQ changes.

What still requires a restart:

  • Changing a method signature (parameters or return type) of a method already running.
  • Changing inheritance.
  • Editing generic types or methods that are already instantiated in some cases.

Hot Reload is enabled by default in Visual Studio 2022 and later. In VS Code with the C# Dev Kit, it's available via the run command. To use it: run the app under the debugger (or with dotnet watch run for command-line scenarios), edit code, save, and the running process picks up the change.

A practical example with a wrong discount calculation:

With Hot Reload, there's no need to stop the program. Change the body to:

Save. The next iteration of the loop uses the new body. The output changes from $90.07 to $80.97 on the next print, no restart needed.

Where Hot Reload is especially useful:

  • UI development. Tweak a Blazor or WPF view, save, see the change without losing app state.
  • Long-running setup. If the bug appears only after loading a million rows, restarting to retest one tweak takes minutes. Hot Reload makes the iteration instant.
  • Heisenbugs. Bugs that disappear when things change. Hot Reload allows smaller, less drastic changes.

Hot Reload tracks state of the changed methods. Some changes (especially to closures or initialization logic) can leave the app in a partially-old, partially-new state. If behavior becomes inconsistent after several Hot Reloads, restart and re-test.

Debugging Async Code

Async code is harder to debug because the call stack in a paused debugger doesn't always reflect the logical chain of awaits. The physical stack ends at the I/O completion thread; the chain of methods that awaited each other is reconstructed from continuations.

Two windows handle this:

  • Parallel Stacks. Shows all threads (or all tasks) and their call stacks side by side. Useful for seeing what other threads are doing while one thread is paused. In Visual Studio, Debug > Windows > Parallel Stacks. Switch between Threads view and Tasks view at the top.
  • Tasks window. Lists every Task in the process: ID, status, location, parent. Filter by status (Running, Awaiting, Completed) to find the relevant ones. Double-click to switch the debugger context to that task.

A small async program:

Set a breakpoint inside FetchOrderAsync on the return line. Run the program. The debugger pauses with one of the three tasks completing. The Tasks window shows all three tasks, two of them Awaiting, one Running. Parallel Stacks shows the three logical paths from ProcessOrders through FetchOrderAsync.

A conceptual view of an async stack:

Three things to watch for in async debugging:

  • `async void` methods. The debugger can't always show the original caller in the stack, because there's no Task to track. Avoid async void outside of event handlers.
  • Captured locals. When a method awaits, its locals are hoisted into a state machine object. The debugger reconstructs the view, but in rare cases the displayed value is stale. Pin the value or re-evaluate in Immediate to refresh.
  • Synchronization context. A ConfigureAwait(false) on an await changes which thread resumes after. If the symptom is "the UI thread isn't running this," check the Threads window to see which thread the continuation ended up on.

A specific case to watch for: a Task that throws but is never awaited. The exception is discarded unless the task is observed. With "break on thrown" enabled for TaskCanceledException and similar types, these surface. Otherwise, set up TaskScheduler.UnobservedTaskException as a fallback.

Debugging Tests

Tests are programs too, and they get debugged the same way as any other code. The IDE integration adds two conveniences: running and debugging a specific test from the Test Explorer.

Run vs Debug. "Run Test" executes without the debugger attached. Fast, no breakpoints honored. "Debug Test" attaches the debugger, so every breakpoint (in test code or in the production code the test calls) hits.

A small xUnit test for the discount calculation:

To debug, click "Debug Test" in Test Explorer (or right-click the test name and pick "Debug"). The test runner attaches the debugger before executing your test method. Breakpoints inside the test or inside the code-under-test fire normally. F10 and F11 work as usual.

Attaching to a running process. Sometimes a test runs as part of a build pipeline and needs local debugging. The general pattern works for any process: Debug > Attach to Process in Visual Studio, then pick the process by name. Useful for:

  • Background workers (Windows services, systemd services on Linux).
  • Long-running test fixtures (integration tests with a real database).
  • Multi-process scenarios (a client test process and a server process you also wrote).

For a locally started process, attach is straightforward. For a process on another machine, remote debugging is required, which involves running the Visual Studio remote debugger tool on the target machine and authenticating to it.

A short table of test-debug entry points:

ScenarioAction
Single test in IDERight-click in Test Explorer, "Debug"
Whole classRight-click the class name, "Debug Tests"
Test running in CI you want to reproduceRun locally with the same fixture, attach
Test that fails only with a specific seedSet up a conditional breakpoint on the seed value

Production Debugging: dotnet-dump, dotnet-trace, dotnet-counters

Once code is running in production, the IDE debugger is rarely usable. Either customer traffic can't be paused, or the production environment doesn't allow remote debugger ports, or the bug only appears under load that can't be reproduced locally. The .NET CLI ships a set of diagnostic tools for this situation. Install them once with the global tool commands:

Each tool answers a different question.

`dotnet-counters` shows live performance counters: CPU, working set, GC heap size, exception rate, gen 0/1/2 collection counts, ThreadPool queue length, and many more. Attach by process ID:

The terminal updates roughly every second. Useful for catching memory leaks (working set growing without bound), GC pressure (gen 2 collections climbing), thread pool starvation (queue length growing), and high allocation rates.

A sample of the output:

`dotnet-trace` records an event-level trace: which methods were called, how long they took, what allocations happened, what GC events fired. Output is a .nettrace file you open in PerfView or Visual Studio. Capture for 30 seconds:

For typical performance investigations, use the cpu-sampling profile (the default). For allocation-heavy diagnostics, switch to gc-verbose. The output file can be loaded in Visual Studio's Performance Profiler (Open File > Diagnostic Session) for a flamegraph view.

`dotnet-dump` captures a full memory dump of a running process and lets you inspect it offline. Capture:

Analyze:

The analyzer opens a SOS-like prompt. Useful commands:

CommandWhat it shows
clrstackManaged call stack of the current thread
clrthreadsAll managed threads and their states
dumpheap -statHistogram of objects on the GC heap by type
dumpheap -mt <address>Every instance of the type with that method table
pePrint exception on the current thread
gcroot <address>Why this object is still alive (who's keeping a reference)
dsoDump stack objects, useful for inspecting locals in the dump

A common use case is a memory leak. Take two dumps an hour apart. Run dumpheap -stat on each, diff the counts. The type that grew the most is the leak. Then dumpheap -mt <address> to find instances, and gcroot on one of them to see what's holding it.

SOS in WinDbg. SOS is the underlying debugger extension that dotnet-dump analyze uses. WinDbg provides a fuller debugging experience for deeper investigations (especially on Windows, especially for native interop scenarios). Load SOS in WinDbg with:

Then the same clrstack, dumpheap, gcroot commands work. WinDbg adds a script engine, the ability to set breakpoints in native code, and integration with the kernel debugger for OS-level scenarios.

SOS and WinDbg aren't daily-use tools. Most production debugging is dotnet-counters for live observation and dotnet-dump after the fact. Knowing they exist, and the kinds of questions they answer, separates "I can't debug this" from "this will take an afternoon."

dotnet-trace captures a lot of data. A 30-second trace can produce hundreds of megabytes. Use the shortest practical --duration, and prefer cpu-sampling over gc-verbose unless allocation events are specifically needed.

Logging-Driven Debugging

When no debugger is attached and no dump is feasible, logs are the available tool. Production debugging leans on them heavily.

A few rules that make logs useful:

  • Include correlation IDs. Every request, job, or message gets a unique ID, and every log line for that work item carries the ID. Microsoft.Extensions.Logging integrates with ActivityId for this. Without correlation, following one user's request through ten log lines mixed with thousands of others becomes impossible.
  • Log at boundaries. Method entry/exit logging is overkill. Logging at boundaries (HTTP requests in/out, database queries, external API calls) shows which subsystem is slow or failing without flooding the log.
  • Structured logging, not strings. _logger.LogInformation("Order {OrderId} processed in {Ms} ms", orderId, elapsedMs) allows filtering by OrderId in a log aggregator. _logger.LogInformation($"Order {orderId} processed in {elapsedMs} ms") produces a string that can only be greped.
  • Log levels matter. Default to Information for normal flow, Warning for recoverable problems, Error for actual failures, Debug for verbose internals to enable only when investigating.

A small example using Microsoft.Extensions.Logging:

In production, the rendered log lines might look like:

Logging is a method-design decision. Methods that fail without diagnostic output are unsalvageable in production. Methods that log their inputs, their key decisions, and their outcomes provide something to read when the debugger isn't available.

Logging has a cost. Each log line costs an allocation (the parameter array), a formatter call, and an I/O write. At very high request rates, log-level filtering at the source is mandatory. if (_logger.IsEnabled(LogLevel.Debug)) _logger.LogDebug(...) avoids the allocation when Debug is off, which the structured overloads also handle internally.

Debug.Assert vs Throwing

There are two ways to flag "this state shouldn't happen": Debug.Assert and throwing an exception. They serve different purposes.

`Debug.Assert` is compiled out of release builds. The body of the assertion is preserved, but the call itself is removed by the C# compiler when the DEBUG symbol isn't defined. Use it for invariants that should never fail in correct code:

In debug builds, calling ApplyDiscount(100m, 1.5m) triggers an assertion dialog (Windows) or terminal message (Linux/Mac) with the message and stack trace. In release builds, the assertion is gone, and the bad input produces a nonsense result. This is why Debug.Assert is for invariants: things that, if they ever fail, indicate a bug in this code that needs fixing. Not user input. Not external state.

Throwing is for conditions that can fail in correct code, just rarely. Bad user input. A missing file. A network call timed out. These are real possibilities, and the code has to handle them:

The exception is real code that runs in both debug and release. Callers can catch it, wrap it, or let it bubble. The validation is part of the contract.

A side-by-side rule:

SituationUse
"If this is ever wrong, the code has a bug"Debug.Assert
"This can be wrong because of external input or state"Throw an exception
Public API parameter validationThrow
Private helper, internal invariantsDebug.Assert
Defensive check that costs a lot at runtimeDebug.Assert (compiled out)
Anything user-facing should see a real errorThrow

A common mistake is using Debug.Assert for input validation. The "asserted" check disappears in release, the bad input flows through, and the bug manifests in production as a corrupted result instead of a clean exception. Avoid that pattern.

A third option, Trace.Assert, is the same idea as Debug.Assert but stays in release builds. Useful in rare cases where an assertion-style check should remain in production, but throwing is almost always a better fit for production-relevant checks.

Putting It Together

A worked example. The bug: an e-commerce order shows Total: $100.00 when it should show $72.00. The order has subtotal $80, a 10% discount, and 10% tax. Expected breakdown: discount of $8, taxable amount of $72, tax of $7.20, grand total of $79.20. The actual breakdown is $0 discount, $80 taxable, $8 tax, $88 total. The reported total is $100, so two things are wrong.

The starting code:

Current (wrong) output:

Charlie's order is the easiest to reason about, so we focus there. Expected: $80 * 0.9 = $72, then $72 * 1.10 = $79.20. Actual: $96.

Step 1: Form the hypothesis. The reported total is too high. Two possibilities: the discount isn't reducing the subtotal, or the tax is being applied to the wrong number. Quick visual reading of ComputeTotal shows two suspicious things: tax is applied to order.Subtotal directly (not the discounted amount), and the return adds discount instead of subtracting it. Test the more surprising one first: "discount is being added to the total instead of subtracted."

Step 2: Set a conditional breakpoint. A line breakpoint on the return line of ComputeTotal with the condition order.Customer == "Charlie". The debugger stops only on Charlie's iteration; the other three run past.

Step 3: Inspect state. Locals window shows:

Watch expression taxed - discount shows 80.000. Watch expression taxed + discount shows 96.000. The return statement is taxed + discount, which explains why Charlie's total is $96 instead of $72. First bug confirmed.

The [DebuggerDisplay] attribute makes Locals readable. Without it, order would show as Order, requiring expansion to see the values.

Step 4: Fix the first bug, re-run. Change the return to taxed - discount. Charlie's output becomes $80. Still wrong; expected $79.20.

Step 5: Second hypothesis. Now the suspect is "tax is applied to subtotal instead of discounted amount." Test: with subtotal = 80, discount = 8, tax = 10%, the correct sequence is 80 - 8 = 72, then 72 * 1.10 = 79.20. The current sequence is 80 * 1.10 = 88, then 88 - 8 = 80. Reorder the math: compute the discounted amount first, then tax on that:

Step 6: Verify. Re-run. Charlie now shows $79.20. Sanity-check the other three:

  • Alice: $89.97 * 0.9 * 1.10 = $89.0703. Output: $89.0703. Correct.
  • Bob: $49.50 * 1.0 * 1.08 = $53.46. Output: $53.46. Correct.
  • Dave: $120 * 0.85 * 1.10 = $112.20. Output: $112.20. Correct.

Step 7: Lock it in. Add an invariant for safety. A discount rate over 1.0 is a programmer error (user input would have been validated earlier), so Debug.Assert fits:

If a future change ever passes a rate of 1.5 (because someone treated 15% as 15 instead of 0.15), the assertion fires in development long before the bug ships.

What happened in total:

  • A specific bug report turned into two concrete hypotheses.
  • A conditional breakpoint isolated the failing iteration in a loop of four.
  • [DebuggerDisplay] made the Locals view readable.
  • Watch expressions tested each hypothesis without modifying source.
  • One hypothesis was wrong, one was right, both were ruled in or out by inspection.
  • A Debug.Assert was added to catch the same class of bug if it recurs.

The whole investigation took five minutes, no Console.WriteLine was added, no rebuild was required between hypotheses.

Quiz

Debugging Techniques Quiz

10 quizzes