Debugging and Best Practices

Every programmer writes bugs; good programmers find them quickly and write code that makes them less likely. This lesson covers reading stack traces, using a debugger, letting the compiler catch more mistakes for you, and the conventions that make C# code easy to read and maintain.

Reading a stack trace

When a program crashes, the full output includes a stack trace: the chain of method calls that led to the error, most recent first. Take this program:

C#
var orders = new List<decimal> { 20m, 35m };
Console.WriteLine(Report.Average(orders, skip: 2));

static class Report
{
    public static decimal Average(List<decimal> values, int skip)
    {
        var remaining = values.Skip(skip).ToList();
        return Sum(remaining) / remaining.Count;
    }

    static decimal Sum(List<decimal> values) => values.Sum();
}
Output
Unhandled exception. System.DivideByZeroException: Attempted to divide by zero.

In the terminal, that first line is followed by the trace (shortened here):

TXT
Unhandled exception. System.DivideByZeroException: Attempted to divide by zero.
   at System.Decimal.DecCalc.VarDecDiv(DecCalc& d1, DecCalc& d2)
   at System.Decimal.op_Division(Decimal d1, Decimal d2)
   at Report.Average(List`1 values, Int32 skip) in /home/sam/demo/Program.cs:line 9
   at Program.<Main>$(String[] args) in /home/sam/demo/Program.cs:line 2

Read it like this:

  1. The first line says what went wrong: the exception type and message.
  2. Skip the frames inside .NET itself (System.…) until you reach your code — Report.Average, line 9.
  3. The frames below show how you got there: Main called Average from line 2.

Here, skipping 2 of 2 orders left an empty list, so the division by remaining.Count divided by zero. The fix might be to check for an empty list, or to reject invalid skip values.

Using the debugger

Printing values works, but a debugger is far more powerful. In VS Code (with C# Dev Kit), Visual Studio or Rider:

  • Breakpoint — click in the margin next to a line (or press F9). Execution pauses there.
  • Start debugging — F5. The program runs until it hits a breakpoint or an exception.
  • Step over (F10) runs the current line; step into (F11) follows a method call inside; step out (Shift+F11) finishes the current method.
  • Variables and Watch panels show current values; hover over any variable in the editor to see it.
  • Conditional breakpoints pause only when an expression is true — for example i == 999 in a long loop.
  • Call stack shows the same chain as a stack trace, and you can click any frame to inspect its variables.

Debuggers can also be set to break when an exception is thrown, before anything catches it — ideal for finding where an error really starts.

Assertions and logging

Debug.Assert documents assumptions and checks them in Debug builds only (in Release builds the calls are removed). For diagnostics, write to the standard error stream, which stays separate from normal output:

C#
using System.Diagnostics;

static decimal ApplyRate(decimal amount, decimal rate)
{
    Debug.Assert(rate is >= 0 and <= 1, "rate must be a fraction");
    Console.Error.WriteLine($"[debug] amount={amount} rate={rate}");
    return amount * rate;
}

Larger applications use a logging library — Microsoft.Extensions.Logging, built into ASP.NET Core — with levels (Debug, Information, Warning, Error) that can be switched on and off without changing code.

Let the compiler help

The cheapest bug is one the compiler catches. Treat warnings seriously, and consider making them errors so they can't be ignored. In the project file:

XML
<PropertyGroup>
  <Nullable>enable</Nullable>
  <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
  <AnalysisLevel>latest-recommended</AnalysisLevel>
</PropertyGroup>

AnalysisLevel turns on more of the built-in .NET code analysers, which flag issues like culture-sensitive string comparisons, disposable objects that aren't disposed, and inefficient LINQ usage. An .editorconfig file in the repository sets formatting and naming rules for everyone, and dotnet format applies them automatically.

Common C# bugs and how to avoid them

BugPrevention
NullReferenceExceptionKeep nullable reference types on and fix every warning (lesson 43).
Integer division (7 / 2 == 3)Make one operand a double or decimal (lesson 8).
Money stored in doubleUse decimal.
Off-by-one loopsPrefer foreach; use i < Length, not <=.
Modifying a collection during foreachUse RemoveAll, loop backwards, or build a new list (lesson 27).
Forgetting to disposeusing declarations (lesson 53).
Culture-dependent parsingCultureInfo.InvariantCulture for data (lesson 9).
Repeated enumeration of a LINQ queryMaterialise with ToList() once (lesson 49).
Blocking on async codeawait, never .Result (lesson 57).
Shared data between threadsAvoid sharing, or use locks and concurrent collections (lesson 58).

Writing readable C#

  • Names: PascalCase for types, methods and properties; camelCase for locals and parameters; _camelCase for private fields; interfaces start with I; async methods end with Async.
  • Small methods that do one thing, with names that say what.
  • Guard clauses at the top of a method instead of deep nesting.
  • Immutability by default: readonly fields, init properties, records.
  • Encapsulation: private fields, public properties and methods that keep objects valid.
  • Pattern matching and switch expressions instead of long if chains.
  • Comments that explain why, not what — the code already says what.
  • var when the type is obvious from the right-hand side; the explicit type when it isn't.

Before and after

The same logic, first written in a hurry, then cleaned up:

C#
Console.WriteLine(Calc(new List<int> { 3, 8, 12 }, 1));

static string Calc(List<int> l, int t)
{
    string r = "";
    if (l != null)
    {
        if (l.Count > 0)
        {
            int s = 0;
            for (int i = 0; i <= l.Count - 1; i++) { s = s + l[i]; }
            if (t == 1) { r = "Total: " + s; } else { r = "Average: " + (s / l.Count); }
        }
    }
    return r;
}
Output
Total: 23
C#
Console.WriteLine(Summarise([3, 8, 12], SummaryKind.Total));
Console.WriteLine(Summarise([3, 8, 12], SummaryKind.Average));

static string Summarise(IReadOnlyCollection<int> values, SummaryKind kind)
{
    if (values.Count == 0) return "";

    return kind switch
    {
        SummaryKind.Total => $"Total: {values.Sum()}",
        SummaryKind.Average => $"Average: {values.Average():F1}",
        _ => throw new ArgumentOutOfRangeException(nameof(kind)),
    };
}

enum SummaryKind { Total, Average }
Output
Total: 23
Average: 7.7

The second version has meaningful names, an enum instead of a magic number, a guard clause instead of nesting, LINQ instead of a manual loop, and no accidental integer division in the average. It also accepts any read-only collection, not just a List.

Debug and Release builds

dotnet build and dotnet run use the Debug configuration: little optimisation, full debugging information. For anything you measure or ship, use Release (dotnet run -c Release, dotnet publish), which can be much faster. Never judge performance from a Debug build — and measure with a benchmarking tool such as BenchmarkDotNet before optimising.

Try it yourself

This method has a bug. Run it, read the output, and fix it so the average of 1 and 2 is 1.5.

Try it yourself · Program.cs
Edit the code, copy it into Program.cs and run it with dotnet run Program.cs (.NET 10 or later). How to set up .NET
Output
1
4